Manually writing types for communicating with API sucks. Why do we even need them? And if we do, why not generate them? What else can we generate? Let's look at a library called Orval and how it can help us.
Why do we need types for communication with API?
Most frontend (web, mobile, etc.) apps do not store their data locally; they instead store it on a server somewhere. This server then knows all about this data - how it is stored, what it contains, and more importantly - in what format it should arrive on the server and in what format it will respond to. If we communicate with that server incorrectly, it might not allow us to manage that data.
Let's look at an example TypeScript code, how these types and communication with API might look.
export interface GetPeopleParams {
search?: string;
}
export interface Person {
birth_year: string;
name: string;
url: string;
}
export interface PeopleResponse {
count: number;
results: Person[];
}
async function getPeople(params: GetPeopleParams): Promise<PeopleResponse> {
const searchParams = new URLSearchParams();
searchParams.set('search', params.search ?? '');
const response = await fetch(`https:/swapi.dev/api/people?${searchParams}`);
if (response.ok) {
return (await response.json()) as PeopleResponse;
}
throw new ApiError('Http Response not ok', response);
}
First, we defined the types for both request and response and then we used those in a function that communicated with the API through a Fetch call. These types allow us (and others using this function) to understand what data the API (and, therefore, this function) needs and how we can work with the result.
Disclaimer: This approach is practical when working with REST API. You will not need this if you're working with TRPC, GraphQL, GRPC, or any other technology that solves this issue.
How to generate types?
Before we can answer that, let's recap what the options would be without it. As programmers, we have several options: We wrote the server or have access to the code; in that case, we can just copy/rewrite the types. We tried using the endpoints and, through trial and error (or maybe some error messages), have figured out what the server needs and what it responds with. This is not really suitable for larger apps. The server has an OpenAPI specification. This describes all the endpoints of the API, what data it requires, and what it provides, which looks promising. OpenAPI documentation provides us with all the information we need and is, therefore, the ideal source of information for our code generation. We can use the aforementioned Orval library for that.
Disclaimer: This approach is practical when working with REST API. You will not need this if you're working with TRPC, GraphQL, GRPC, or any other technology that solves this issue.
Generating code from documentation
Orval can generate not only the types we need but also functions for communicating with the API (like our “getPeople” function) and much more. Its configuration can be complex, but its initial usage is not that complicated. First, we need to create an orval.config.ts file (we can also use JavaScript instead) and export an object with the necessary instructions for Orval: Name of the API. We could be using more than one, so this name will distinguish them. For each API, the library needs to know where to get the documentation and where to output the code. We can also customize the base URL, which will be prepended to all the endpoints, and, for example, whether to use Prettier.
For our project using Star Wars API, it could look something like this:
export default {
demo: {
input: './api-docs.json',
output: {
target: './src/modules/api/orval',
baseUrl: 'https://swapi.dev/api',
prettier: true,
},
},
};
Note: For better typing one might use types or the “defineConfig” function provided by Orval.
Generating code can then be executed by running “npx orval” in the project directory. The command will notify us if something failed, but if everything ran fine, the code should be generated in the specified folder.
export type GetPeopleParams = {
search?: string;
page?: number;
};
export interface Person {
birth_year: string;
name: string;
url: string;
}
export interface PeopleResponse {
count: number;
results: Person[];
}
/**
* @summary Get all people in all movies
*/
export const getPeople = <TData = AxiosResponse<PeopleResponse>>(
params?: GetPeopleParams,
options?: AxiosRequestConfig,
): Promise<TData> => {
return axios.get(`https://swapi.dev/api/people`, {
...options,
params: { ...params, ...options?.params },
});
};
We can see that the code is fairly similar to the one we wrote earlier. The benefit is that we do not have to write it ourselves, and if anything changes on the server, we can just regenerate the code and see the changes immediately in our application. By default, Orval uses Axios, which was historically very popular for communicating with servers. If we wanted to, it is possible to configure Orval to use, e.g., Fetch API.
At Ackee, we use Tanstack Query to manage asynchronous data. This library helps us manage UI in various states of obtaining data from the server (mainly loading, error, and success). It also provides an easy way of caching the data, retrying the request if something fails, prefetching after some time, and much more. Using our generated function with this library could look something like this:
export default function People() {
const query = useSearchParams().get(SEARCH_QUERY_KEY) || undefined;
const {
data: response,
isPending,
error,
} = useQuery({
queryKey: ['people', query],
queryFn: () => getPeople({ search: query }),
});
const data = response?.data;
// TODO render people
}
What else can we generate with Orval?
Even in the intro, we could see that Orval can do much more than we initially hoped for - in addition to the types, it could also generate functions for communicating with the API. Aside from that, it can also generate other pieces of code; we can take advantage of it being able to generate React hooks for Tanstack Query.
This can be configured by adding “client: 'react-query' to the output part of the configuration.
export default {
demo: {
input: './api-docs.json',
output: {
target: './src/modules/api/orval',
baseUrl: 'https://swapi.dev/api',
prettier: true,
client: 'react-query',
},
},
};
After executing the code generation once more, we can see our generated types, functions for communication, aforementioned hooks, and other utility functions. These generated hooks basically look like this:
/**
* @summary Get all people in all movies
*/
export const useGetPeople = (params?: GetPeopleParams, options?: ...) => {
const { query: queryOptions, axios: axiosOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetPeopleQueryKey(params);
const queryFn = ({ signal }) => getPeople(params, { signal, ...axiosOptions });
const query = useQuery({ queryFn, queryKey, ...queryOptions });
query.queryKey = queryKey;
return query;
};
The code is a bit simplified, but it performs all we wrote before ourselves but a bit better. It prepares query key - key for caching responses. It prepares a function for getting the query data. It also uses a signal, which allows Tanstack Query to stop the request, for example, if the data is no longer needed. It invokes the useQuery hook from Tanstack Query and returns its result. It also allows us to customize any of the previous steps; we can, therefore, change the used query key, change the caching strategy, or pass some parameters to Axios. Its usage is now even simpler than before. All we need to do is call the generated hook, and we will have access to the same data as before.
export default function People() {
const query = useSearchParams().get(SEARCH_QUERY_KEY) || undefined;
const { data: response, isPending, error } = useGetPeople({ search: query });
const data = response?.data;
// TODO render people
}
Alternatives – what's missing in Orval
When researching this at Ackee, we also looked at other solutions. One of those was Zodios, which takes a different approach. In Zodios, every endpoint always has validation rules in the form of schemas from the Zod library; these can be used to infer the types. This allows us to validate the incoming data and spot possible bugs in documentation, which could create more bugs in our application - an example of that may be a missing property or a different type.
In the end, we chose Orval over Zodios. Its usage of type inference can mean worse performance in IDEs, it requires a runtime library, and its conventions for Tanstack Query hooks can be annoying and are harder to debug due to response handling logic being somewhere in the library. Response validation is a great thing, but its absence in Orval is not a dealbreaker. And who knows, one day it might make it to Orval too.
Summary: Code generation with Orval
We discussed why types of communication are necessary and how to generate them using Orval. Its configuration was pretty straightforward, and it helped us generate more than we initially wanted - be it types, fetch functions, or Tanstack Query hooks.
Orval is a great tool, and we at Ackee have been using it for some time. It allows us to customize every part of the code generation process, and in the default configuration, it only requires Axios to run in our app. What about you? What have you been using to solve this issue? Do you use Orval, or have you chosen another solution? Hit us up on Twitter or come to our next Ackee meets.
Sources: Docs Orval Docs Zodios Docs React Query Star Wars API Example repository Talk from Ackee meets